iT邦幫忙

2026 iThome 鐵人賽

DAY 3
2
Modern Web

Angular 22 Signal 進化論系列 第 3

Day3:從 set()、update() 看懂 Angular Signal 的基本使用與更新機制

  • 分享至 

  • xImage
  •  

昨天我們先試著理解,在 Template 中使用 Signal() 時,Angular 是如何建立依賴關係,並在 Signal 發生變化後,知道哪些 View 可能受到影響。

不過前面其實還沒有正式介紹 Signal 本身怎麼使用。因此今天會先回到基礎,從 Signal() 的基本使用方式開始,再接著看看它在更新資料時,Angular 內部大致做了哪些事情。

和昨天一樣,除了基本用法之外,我們也會搭配一些原始碼,試著從實作角度理解它背後的運作方式。

Signal 基本認識

大致上可以先把 Signal 分成兩種類型:

  • Writable Signal:可以讀取,也可以修改,一般會持續變動的狀態適合使用。
  • Readonly Signal:只能讀取,不能直接從外部呼叫 set()update() 修改資料。當我們想把某個 Signal 提供給其他地方使用,但又不希望外部隨意改動時,就很適合用這種方式。

不過要注意,如果 Signal 裡面放的是物件,直接去修改物件裡的屬性,這件事情其實還是可以做得到,Readonly 並不會幫你把物件本身也一起鎖住喔!

我們可以快速透過一個簡單的範例,看看 Writable SignalReadonly Signal 在實際開發上的差別。

counter.ts

補充:這裡的 @Service() 是 Angular 22 新增的寫法,可以先理解成建立 Root Service 的簡化方式,概念上類似過去常見的 @Injectable({ providedIn: 'root' })

import { Service, signal } from '@angular/core';

@Service()
export class CounterService {
  private readonly _count = signal(0);

  readonly count = this._count.asReadonly();

  increase() {
    this._count.update((value) => value + 1);
  }

  decrease() {
    this._count.update((value) => value - 1);
  }
}

CounterService 內部,我們使用 _count 保存真正可以修改的 Writable Signal,再透過 asReadonly() 對外提供一個只能讀取的 count

這樣其他地方就不能直接修改 _count。如果想改變數值,只能透過 increase()decrease() 這些由 Service 提供的方法來操作。

main.ts

import { Component, inject } from '@angular/core';
import { bootstrapApplication } from '@angular/platform-browser';
import { CounterService } from './app/counter';

@Component({
  selector: 'app-root',
  standalone: true,
  template: `
    <h2>Counter:{{ counterService.count() }}</h2>

    <button (click)="counterService.decrease()">-1</button>
    <button (click)="counterService.increase()">+1</button>
  `,
})
export class AppComponent {
  counterService = inject(CounterService);
}

bootstrapApplication(AppComponent);

stackblitz 範例連結

在 Component 中,我們可以透過 counterService.count() 讀取目前的值,但不能直接使用 set()update() 修改它。

這個差別其實很重要,因為它不只是在區分「能不能修改」,也會直接影響我們後面怎麼設計資料流。

簡單來說,就是先把「誰可以改資料」這件事情限制清楚,外部只負責讀取,真正的修改邏輯則集中在 Service 內部處理。

Angular 中常見的 Signal 相關 API

前面我們先認識了 Writable SignalReadonly Signal,不過實際在 Angular 開發裡,現在除了 Signal 本身之外,也有不少新的 API 開始採用 Signal,或是類似的函式式寫法。

如果先從平常開發的使用情境來看,大致可以分成下面幾類。

狀態與響應式資料

  • signal():建立一般可以讀取與修改的狀態。
  • computed():根據其他 Signal 計算出衍生值,本身不能直接修改。
  • linkedSignal():根據其他 Signal 建立有依賴關係的狀態,同時本身也可以被修改。
  • effect():當依賴的 Signal 發生變化時,自動執行一些額外操作,例如紀錄 log、同步 localStorage,或更新第三方圖表套件。
  • resource():用來把 API 等非同步資料整合進 Signal 的響應式流程,讓資料載入、狀態與更新可以一起管理。

Template 查詢

可以先把「內容投影」想像成 Vue 的 Slot,可以把外面的畫面內容丟進元件裡,讓元件決定要放在哪個位置。

  • viewChild():取得目前元件 Template 中單一符合條件的元素、Directive 或子元件,查詢結果會以 Signal 提供。
  • viewChildren():取得目前元件 Template 中多個符合條件的元素、Directive 或子元件,查詢結果會以唯讀陣列的 Signal 提供。
  • contentChild():取得由外部投影進目前元件中的單一元素、Directive 或子元件,查詢結果會以 Signal 提供。
  • contentChildren():取得由外部投影進目前元件中的多個元素、Directive 或子元件,查詢結果會以唯讀陣列的 Signal 提供。

元件之間的溝通

  • input():用於父元件傳遞資料給子元件,子元件可以透過 Signal 的方式取得輸入值。
  • output():用於子元件向父元件發送事件。
  • model():用於父子元件之間的雙向綁定,可以理解成 Signal 版本的雙向資料溝通 API。

其實不少概念都能在不同框架中找到相似的做法,雖然底層實作細節不一定相同,但先把這些 API 看過一輪,至少可以知道在 Angular Signal 的開發情境中,手上有哪些工具可以使用。

接著回到今天的主軸,我們會把焦點放回 Signal 本身,先看看它有哪些更新資料的方式,再進一步理解這些更新方式在 Angular 原始碼中,實際又是怎麼串起來的。

Signal 的更新方式

Writable Signal 主要可以透過 set()update() 來修改資料。

Angular 在更新 Signal 前,會先比較新舊值是否相同,預設使用 Object.is();如果判定相同,就不會繼續傳遞這次變更。

Angular set 跟 update 比較

  • set():直接指定新的值,適合已經知道下一個值的情境。
const count = signal(0);

count.set(10);

console.log(count()); // 10
  • update():根據目前的值計算下一個值,適合需要依照現有狀態進行修改的情境。
const count = signal(0);

count.update((value) => value + 1);

console.log(count()); // 1

從原始碼理解 Signal 如何判斷是否需要傳遞變更

前面我們知道,Writable Signal 可以透過 set()update() 修改資料。

從 Angular 原始碼來看,兩者最後都會走到相同的更新流程,差別只在於 update() 會先執行傳入的 callback function,根據目前的值計算出新的結果。

從 createSignal() 函數開始理解

建立 Signal 時,可以額外傳入 equal 函式,用來決定新舊值是否相同;如果沒有指定,則會使用預設的 Object.is() 進行比較。

Angular 會先透過 equal 判斷這次的值是否真的發生變化,再決定要不要繼續往下傳。

export function createSignal<T>(
  initialValue: T,
  equal?: ValueEqualityFn<T>,
): [SignalGetter<T>, SignalSetter<T>, SignalUpdater<T>] {
  const node: SignalNode<T> = Object.create(SIGNAL_NODE);

  node.value = initialValue;

  if (equal !== undefined) {
    node.equal = equal;
  }

  const getter = (() => signalGetFn(node)) as SignalGetter<T>;

  const set = (newValue: T) => signalSetFn(node, newValue);
  const update = (updateFn: (value: T) => T) =>
    signalUpdateFn(node, updateFn);

  return [getter, set, update];
}
export function signalUpdateFn<T>(
  node: SignalNode<T>,
  updater: (value: T) => T,
): void {
  signalSetFn(node, updater(node.value));
}

可以看到,set()update() 最後都會走到 signalSetFn(),接下來就從這裡看看 Angular 怎麼判斷這次更新要不要繼續往下傳。

export function signalSetFn<T>(node: SignalNode<T>, newValue: T) {
  if (!producerUpdatesAllowed()) {
    throwInvalidWriteToSignalError(node);
  }

  if (!node.equal(node.value, newValue)) {
    node.value = newValue;
    signalValueChanged(node);
  }
}

這裡的重點就是 equal()。Angular 會先用它比較目前的值和新的值,如果兩者相同,這次更新就會直接停止;只有判斷為不同時,才會更新 node.value,並繼續執行 signalValueChanged(),通知後續流程這個 Signal 已經發生變化。

function signalValueChanged<T>(node: SignalNode<T>): void {
  node.version++;
  producerIncrementEpoch();
  producerNotifyConsumers(node);
  postSignalSetFn?.(node);
}

這裡有兩個需要注意的地方:

  • node.version++:代表這個 Producer 真的產生了一個新的值。
  • producerNotifyConsumers(node):開始通知依賴這個 ProducerConsumer

version 可以理解成這個 Producer 目前產出值的版本。只有當 Producer 真的產生不同的新值時,version 才會增加。

比如說:

const count = signal(1);
const isEven = computed(() => count() % 2 === 0);

console.log(isEven()); // false

count.set(3);

console.log(isEven()); // false

這時 count 從 1 變成 3,代表它真的產生了新的值,所以 count 自己的 version 會增加。

不過 isEven() 重新計算後,結果還是 false,代表它最後產出的值其實沒有改變,因此 isEven 自己的 version 不會增加。

version 可以幫助 Angular 判斷,就算上游資料發生了變化,這個 Producer 最後產出的結果,是不是真的和之前不一樣。

本日結語

回顧 Day 2 整理的簡化流程,今天討論的內容,其實就是其中的:

Step 2:Signal 發生變化:確認是否需要傳遞變更

把今天的內容整理成一張圖,大致會像這樣:

Signal 判斷新舊值並通知 Consumer 的流程

從呼叫 set()update() 開始,Angular 會先取得 newValue,再透過 equal() 比較新舊值:

  • 判定相同:直接停止,不做任何事。
  • 判定不同:更新 node.value,接著讓 version 增加,並透過 producerNotifyConsumers() 通知依賴這個 ProducerConsumer

Signal 並不是每次呼叫 set()update() 都會往下傳遞,而是先確認「值真的變了」,才把變更交給後續流程。

被通知到的 Consumer,接下來就會進入 Step 3 標記受影響的 View 與祖先路徑,這部分留到之後再繼續往下看。

資料來源


上一篇
Day 2:Angular Signal 是什麼?從 Change Detection 理解它的運作原理
下一篇
Day 4:Angular 怎麼知道誰正在讀 Signal?從 Reactive Context 理解依賴追蹤
系列文
Angular 22 Signal 進化論6
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言